iT邦幫忙

2022 iThome 鐵人賽

DAY 29
0

Active Link Styling

改用 NavLink,用以判斷 to={"xx/xx"} 是否匹配當前路由

NavLinkLink 的功能是一致的,區別在於可以判斷其to属性 是否是當前匹配到的路由。

NavLink 的 style 或 className 可以接收一個函式,函式接收一個含有 isActiveisPending 的物件為參數,可根據參數調整樣式。

import { ..., NavLink } from "react-router-dom";
<NavLink
  to={`xx/xx`}
  style={({isActive, isPending}) => {...}
  className={({isActive, isPending}) => {...}
>
...
</NavLink>

範例操作

  • Link 改用 NavLink,加上取得樣式對應的函式
// src/routes/root.js
import { NavLink, ... } from "react-router-dom";
...
// 新增樣式對應
const getNavLinkStyles = (status) => {
  const { isActive, isPending } = status;
  if (isActive) return "active";
  else if (isPending) return "pending";
  else return "";
};
...
<NavLink
  to={`contacts/${contact.id}`}
  className={(status) => getNavLinkStyles(status)}
>
...
</NavLink>
  • 以上步驟完成後,會看到以下的畫面

目前操作結果:https://codesandbox.io/s/react-router-tutorial-active-link-styling-gjuxw8

API 參考

useNavigation Hook

利用 useNavigation() 取得路由導航的相關資訊

import { useNavigation } from "react-router-dom";

function SomeComponent() {
  const navigation = useNavigation();
  // 目前路由導航的狀態
  navigation.state; 
  // 路由導航完成後,下一頁的位置
  navigation.location;
  // 路由導航 action 對應 Form 的相關資訊
  navigation.formData;
  navigation.formAction;
  navigation.formMethod;
}

範例操作

  • 使用 useNavigation Hook 取得目前路由導航的狀態,然後再為其添加對應的樣式。
// src/routes/root.jsx
import { ..., useNavigation } from "react-router-dom";
...
const navigation = useNavigation();
...
<div
  id="detail"
  className={
    navigation.state === "loading" ? "loading" : ""
  }
>
  <Outlet />
</div>
/* src/index.css */
#detail.loading {
  opacity: 0.25;
  transition: opacity 200ms;
  transition-delay: 200ms;
}
  • 以上步驟完成後,切換不同的聯絡人會看到 loading 的漸變畫面

目前操作結果:https://codesandbox.io/s/react-router-tutorial-use-navigation-efyqpk

API 參考

再探 Form / Action / errorElement

Form 元件指定 Action 時,路由會加上 /actionName

<Form action="edit">
  <button type="submit">Edit</button>
</Form>
<Form
  method="post"
  action="destroy"
>
  <button type="submit">Delete</button>
</Form>
const router = createBrowserROuter([
  {
    path: "xxx/:id",
    element: ...,
    loader: ...
  },
  {
    path: "xxx/:id/edit",
    element: ...,
    loader: ...,
    action: ...
  },
  {
    path: "xxx/:id/destroy",
    action: ...
  },    
])

範例操作

  • 完成聯絡人詳細資訊頁面上的「Delete」功能
// src/routes/contact.jsx
...
// 加上 Delete Form Submit 事件對應
const onDeleteSubmit = (event) => {
  if (!window.confirm("Please confirm you want to delete this record.")) {
    event.preventDefault();
  }
};
...
<Form method="post" action="destroy" onSubmit={onDeleteSubmit}>
  <button type="submit">Delete</button>
</Form>
  • 增加 contacts/:contactId/destroy 路由的 aciton function 對應
import { ..., deleteContact } from "./contacts";
...
const contactDeleteAction = async ({ params }) => {
  await deleteContact(params.contactId);
  return redirect("/");
};
...
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />, // 要加入的首頁元件
    ...,
    children: [
      {...},
      {...},
      {
        path: "contacts/:contactId/destroy",
        action: contactDeleteAction
      }
    ]
  }
]);
  • 以上步驟完成後,在聯絡人詳細資訊頁面按下「Delete」功能,就能刪除成功

  • 在 delete action function 故意丟出錯誤
const contactDeleteAction = async ({ params }) => {
  // FIXME: 故意丟出錯誤,用以後續加上對應路由的 errorElement
  throw new Error("oh dang!");
  await deleteContact(params.contactId);
  return redirect("/");
};
  • 以上步驟完成後,在聯絡人詳細資訊頁面按下「Delete」功能,刪除失敗會套用 Root 的 ErrorPage 元件

  • 在 contacts/:contactId/destroy 路由操作,發生錯誤時,增加 errorElement
...
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />, // 要加入的首頁元件
    ...,
    children: [
      {...},
      {...},
      {
        path: "contacts/:contactId/destroy",
        action: contactDeleteAction,
        errorElement: <div>Oops! There was an error.</div>
      }
    ]
  }
]);
...
  • 以上步驟完成後,在聯絡人詳細資訊頁面按下「Delete」功能,刪除失敗時,會顯示在當前路由下的 errorElement

目前操作結果:https://codesandbox.io/s/react-router-tutorial-delete-4rrpkm

Index Routes

當在巢狀路由中,指定一個路由設定為 index:true 用以取代 { path: ''},那麼這個路由,就會是上層路由的預設渲染路由頁面。

const router = createBrowserRouter([
  {
    path: "/",
    ...
    children: [
      { index: true, element: ... },
      /* existing routes */
    ],
  },
]);

範例操作

前面的範例,在巢狀路由上還沒設定預設路由,所以整個頁面重新進入時,會出現下面的畫面,右邊的區塊空白一片。

  • 加上預設路由的對應頁面元件 /src/routes/index.jsx

程式碼放在 gist,供大家抓取。

  • 為巢狀路由加上 Index Route
// src/main.jsx
// import 預設路由頁面元件
import Index from "./routes/index";
...
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />, // 要加入的首頁元件
    ...
    // 設定的巢狀路由
    children: [
      {
        // 預設路由
        index: true,
        element: <Index />
      },
      ...
    ]
  }
]);
  • 以上步驟完成後,重新進入頁面時,就會看到顯示的預設路由頁面

目前操作結果:https://codesandbox.io/s/react-router-tutorial-index-route-1nf66g

API 參考

路由頁面轉導的另一個做法 - useNavigate

useNavigate Hook

React Router DOM 提供了 useNavigate Hook,可以讓你以程式的方式做導航,它會返回一個函式用以做導航的操作。

import { ..., useNavigate } from "react-router-dom";
...
const navigate = useNavigate();
...

這個 navigate 函式,可以使用二種方式操作。

navigate('to-url', {replace, state, relative})

第一個參數是要導航的頁面路由網址

第二個參數是選擇性的,可以用以設置導航操作的各種方式

navigate('/xxxx');
navigate('/xxxx', { replace: true});

navigate(number)

參數傳入一個數字,這個數字是歷史記錄的堆疊數字位置。舉例來說,navigate(-1) 就如同在瀏覽器按下回到上一頁的操作。

navigate(-1);

另一個轉導方式是可以用在 loader 及 action function 的 redirect

範例操作

前面的範例,在編輯聯絡人的頁面上,我們還沒實作「Cancel」,接下來就來完成實作,用以按下「Cancel」後,回到前一頁。

  • 使用 useNavigate 取得 navigate,再用它操作回前一頁
// src/routes/edit.jsx
import { ..., useNavigate } from "react-router-dom";
...
const navigate = useNavigate();
...
<button
  type="button"
  onClick={() => { navigate(-1); }}
>
  Cancel
</button>
  • 以上步驟完成後,按下「Cancel」後,就會回到前一頁

目前操作結果:https://codesandbox.io/s/react-router-tutorial-usenavigate-7bpkgo

API 參考

使用 GET 提交表單,帶入表單欄位參數且改變 URL

之前的路由控制,不是從網址列上輸入URL做變更,就是提交 Form 表單觸發 Action。會觸發 Action 的 Form 表單提交方式,通常是 POST。如果不指定 Form 的表單提交方式,就是預設用 GET。

// Form 沒有指定 method 就是使用 get
<Form action="xxx"> 
  <button type="submit">xxx</button>
</Form>
<Form method="post" action="xxx">
  <button type="submit">xxx</button>
</Form>

用 GET 的形式提交表單,會帶入表單欄位參數且改變 URL

<Form><input name="xxx" value=""/></Form> => url?xxx=value
<Form role="search">
  <input
    id="searchField"
    name="searchField"
    ...
  />
  ...
/>
  ...
</Form>

但不會觸發 Action,你可以在 Loader Function 中取得 URL params。

const loader = ({request}) => {
  const url = new URL(request.url);
  const searchField = url.searchParams.get("searchField");
  const data = getDataBy(searchField);
  return data;

範例操作

  • 把搜尋欄位改為使用 React Router Form 元件,Form 沒有指定method 會用 GET 提交表單,會把表單欄位的參數使用欄位的name帶入到URL上。(eg. /concat?q=xxxx)
// src/routes/root.jsx
<Form id="search-form" role="search">
  <input
    id="q"
    name="q"
    ...
  />
  <div id="search-spinner" aria-hidden hidden={true} />
  ...
</Form>
  • 放置搜尋欄位的頁面元件的路由,可以使用 URL Search Params 取得表單欄位參數,然後利用參數來查詢資料。
// src/main.jsx
const rootLoader = async ({ request }) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return { contacts, q };
};
  • 以上步驟完成後,在搜尋欄位輸入值按下 Enter,就會使用 Get 提交表單,在網址列加上 ?q=xxxx,而 Loader Function 內可以使用 url.searchParams.get("q") 得到參數來查詢聯絡人資料後,顯示聯絡人資料查詢結果在畫面上。

目前操作結果:https://codesandbox.io/s/react-router-tutorial-url-search-params-fbnzty

將 URL Params 連動到表單

範例操作

  • 如果網址列上的 URL Search Params 的值變更,也將值反饋至搜尋欄位做連動。
// src/routes/root.jsx
import { useEffect } from "react";
export default function Root() {
  const { contacts, q } = useLoaderData();
  ...
  // 當 url 的 q 變動,欄位 q 也會跟著連動
  useEffect(() => {
    document.getElementById("q").value = q;
  }, [q]);
  ...
  <Form id="search-form" role="search">
    <input
      id="q"
      name="q"
      defaultValue={q}
      ...
    />
    {/* existing code */}
  </Form>
  ...
}
  • 以上步驟完成後,現在網址列怎麼變動都會連動至表單欄位上

目前操作結果:https://codesandbox.io/s/react-router-tutorial-url-search-params-advance-9wwdgn

使用 useSubmit 隱式提交表單

透過 useSubmit Hook 取得的 submit 函式,可以不需要再按下 Enter 做表單提交,而是可以把 submit 函式放在你要控制的事件當中,做表單提交。

import { ..., useSubmit } from "react-router-dom";
...
const submit = useSubmit();
...
<input 
   ...
   on事件={(event) => {
     submit(event.currentTarget.form);
   }}
/>

範例操作

  • 加入 useSubmit 讓我們在欄位的 onChange 事件,自動提交表單資料。
// src/routes/root.jsx
import { ..., useSubmit } from "react-router-dom";
...
const submit = useSubmit();
...
<input
   id="q"
   ...
   onChange={(event) => {
     submit(event.currentTarget.form);
   }}
/>
  • 以上步驟完成後,搜尋欄位輸入值就會自動提交表單,聯絡人資料也跟著搜尋欄位去顯示查詢的結果

目前操作結果:https://codesandbox.io/s/react-router-tutorial-usesubmit-hvv1rr

API 參考

再探 useNavigation 及 useSubmit

useNavigation

使用 navigation.state 去判斷路由導航時的狀態。(idle/submitting/loading)

而 navigation.location 則是路由導航完成後,下一頁的位置。如果navigation.state 為 loading 時,location 就會有值,反之,loaction 會變空值,這也可以做為判斷的依據。

const navigation = useNavigation();
...
// 如果正在搜尋
const searching =
  navigation.location &&
  new URLSearchParams(navigation.location.search).has(
    "your-search-field"
  );

useSubmit

useSubmit 可以讓我們用程式化的方式,提交 Form 表單。

函式的第一個參數(Required) - Form 表單的資料。

函式的第二個參數(Optional) - 提交 Form 表單的額外資訊。

程式化指定 Form 表單的 action 及 method

const submit = useSubmit();
...
submit(null, {
  action: "/logout",
  method: "post",
});

// same as
<Form action="/logout" method="post" />;

程式化指定 Form 表單提交後的 URL 是否要被取代

const submit = useSubmit();
...
submit(your-form-data, {
  replace: true or false
});

範例操作

  • 加上 useNavigation 用以判斷,是否正在搜尋關鍵字,正在搜尋的話就顯示 search spinner 用以優化 UX 體驗。
...
const navigation = useNavigation();
...
// 判斷是否正在搜尋
const searching =
  navigation.location &&
  new URLSearchParams(navigation.location.search).has(
    "q"
  );
...
<input
  id="q"
  className={searching ? "loading" : ""}
  // existing code
/>
<div
  id="search-spinner"
  aria-hidden
  hidden={!searching}
/>
  • 在 useSubmit 的函式,傳入第二個參數{replace:...},所以搜尋過程中,不會把 URL 加入到瀏覧歷程中。
<input
  id="q"
  // existing code
  onChange={(event) => {
    const isFirstSearch = q == null;
    submit(event.currentTarget.form, {
      replace: !isFirstSearch,
    });
  }}
/>
  • 以上步驟完成後,搜尋欄位輸入值時,會顯示 search spinner,直到查詢完成,在搜尋過程中,不會把 URL 加入到瀏覧歷程中。

目前執行結果:https://codesandbox.io/s/react-router-tutorial-usenavigation-and-usesubmit-xiq0fq

API 參考

useFetcher and Optimistic UI

useFetcher Hook 可以讓路由不需要導航轉導頁面就可以操作,如同使用 App 一樣,當程式是高度互動的時候,會經使用 useFetcher

import { useFetcher } from "react-router-dom";

function SomeComponent() {
  const fetcher = useFetcher();

  // build your UI with these properties
  fetcher.state; // idle/submitting/loading
  fetcher.formData; // Optimistic UI 

  // render a form that doesn't cause navigation
  return <fetcher.Form />;
}

fetcher.Form

使用 <fetcher.Form> 替代 <Form>,無論指定什麼表單提交方法,都不會造成導航轉導。

function SomeComponent() {
  const fetcher = useFetcher();
  return (
    <fetcher.Form method="post" action="/some/route">
      <input type="text" />
    </fetcher.Form>
  );
}

fetcher.formData

當使用 <fetcher.Form> 或是 fetcher.submit() 時,fetcher.formData 相關的表單資料都可以用以樂觀更新 UI (Optimistic UI) - 也就是先顯示預期它成功的效果,如果失敗了就會回到原本的狀態。

function TaskCheckbox({ task }) {
  let fetcher = useFetcher();

  let status =
    fetcher.formData?.get("status");

  let isComplete = status === "complete";

  return (
    <fetcher.Form method="post">
      <button
        type="submit"
        name="status"
        value={isComplete ? "incomplete" : "complete"}
      >
        {isComplete ? "Mark Incomplete" : "Mark Complete"}
      </button>
    </fetcher.Form>
  );
}

Optimistic UI 是一種前端 UI 快速回應用戶互動的概念,在發送資料給伺服器前,先展示預期成功的效果,用戶不需要等待才能操作下一步,因為對於大部份的用戶操作,服務器都可以成功完成確認更新。

理解 Optimistic UI 後,我們可以把畫面設計成如下

  • 前端預期成功的畫面

  • 若伺服器最終執行失敗的畫面

範例操作

  • 把聯絡人頁面元件上的 Favorite (加入最愛)的元件,改成使用 fetcher.Form,無論怎麼提交表單都不會改變導航。
// src/routes/contact.jsx
import { ..., useFetcher } from "react-router-dom";
function Favorite({ contact }) {
  const fetcher = useFetcher();
  ...
  <fetcher.Form method="post">
    ...
  </fetcher.Form>
}
  • 在檢視聯絡人的路由設置上,加上要操作更新聯絡人的加入最愛的 action function。
// src/main.jsx
const contactUpdateFavoriteAction = async ({ request, params }) => {
  const formData = await request.formData();
  await updateContact(params.contactId, {
    favorite: formData.get("favorite") === "true"
  });
};
...
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    /* existing code */
    children: [
      { index: true, element: <Index /> },
      {
        path: "contacts/:contactId",
        element: <Contact />,
        loader: contactLoader,
        // 加上要操作更新聯絡人加入最愛的 action
        action: contactUpdateFavoriteAction,
      },
      /* existing code */
    ],
  },
]);
  • 使用 fetcher.formData 做 Optimistic UI 更新,讓顯示最愛的切換效果,能更快速的呈現。
function Favorite({ contact }) {
  const fetcher = useFetcher();
  let favorite = contact.favorite;
  if (fetcher.formData) {
    favorite = fetcher.formData.get("favorite") === "true";
  }
  /* existing code */
}
  • 以上步驟完成後,就可以看到加入最愛的功能是沒有更新路由導航網址列,而且也使用 Optimistic UI 更新,做到即時反應的效果。

目前執行結果:https://codesandbox.io/s/react-router-tutorial-usefetcher-optimisticui-unzdrl

API 參考

Pathless Routes

我們在設計路由結構時,儘量讓錯誤頁面顯示在當下操作錯誤的 Outlet,這樣用戶不會看到整個錯誤頁面而毫無頭緒,而是還能操作其他功能。

範例操作

  • 加入找不到聯絡人時會丟出例外錯誤
// src/main.jsx
const contactLoader = async ({ params }) => {
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("", {
      status: 404,
      statusText: "Not Found"
    });
  }
  return contact;
};
  • 以上步驟完成後,在 /contact/ 後隨意輸入 contactId,會顯示全域的錯誤頁面

  • 把錯誤頁面顯示在當下操作錯誤頁面的 Outlet,所以在 childern 之下,再加入 childern,用以把錯誤顯示在有 Sidebar 的根元件頁面上。
children: [
  {
    errorElement: <ErrorPage />,
    children: [
      /* existing code */
    ]
  }
]
  • 以上步驟完成後,在 /contact/ 後隨意輸入 contactId,錯誤頁面會顯示在有 Sidebar 的根元件頁面上。

目前執行結果:https://codesandbox.io/s/react-router-tutorial-contact-not-found-vcxjdt

小結

React Router DOM 官方提供的 Tutorial 雖然跟著做完大概要花上不久的時間,但 step-by-step 的學習,可以完整理解到 React Router DOM 操作的奧妙之處,日後也能做出更多延伸的應用,請試著跟著學習看看。

Next

終於要來到鐵人賽的最後一天了,關於 React 的生態系與相關應用,還有很多沒介紹到的地方,下一篇除了最後的總結,也會儘量搜集一些學習資源供大家與自己日後做參考。

Reference

https://reactrouter.com/en/6.4.1/start/tutorial

https://limboy.me/posts/react-router-6/

https://ithelp.ithome.com.tw/articles/10188245

https://ithelp.ithome.com.tw/articles/10226056

https://ithelp.ithome.com.tw/articles/10226370

https://ithelp.ithome.com.tw/articles/10282773

https://medium.com/%E6%89%8B%E5%AF%AB%E7%AD%86%E8%A8%98/implementing-react-router-dom-bf986888f2ce

https://tehub.com/a/9Nro8iXEsX

https://www.jianshu.com/p/e3f377ac8399


上一篇
Day 28 React Router v6 (上)
下一篇
Day 30 React 技術選型及總結
系列文
開始搞懂React生態系30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言